React Hooks指向のfrontendのarchitectureを考える (2022)
(↓これは2022年の時に思っていた話で、2023ではRSCも登場したし自分の中の方向性も色々変わっていますmrsekut.icon)
最近(2022年)のReactの思想として、「個々のComponentを賢くする」という流れがある
親のComponentが全てfetchして、子にデータを伝搬していくのではなく
個々のComponentが各々でデータをfetchして表示する
headerと、footerで同じデータをfetchして使用する際に、2回requestが送られずに再利用してくれる
Reduxだとこれは少し難しい
request回数を減らすためには、親でuseEffectを呼ぶ必要があり、そうするとCustom Hooksの中で処理を完結できない
何かしらの運用でカバーが必要になる
Layerを作るなら以下のようになる
https://gyazo.com/fa6bf835bc4d33a371c7e48c24e35fa2
この図はUsecaseの立ち位置がちょっと雑mrsekut.icon
code:dir
authenticatons/
types.ts // 型
(entities.ts) // Entity
(usecases.ts) // UseCase
repositories.ts // Repository
useLogin.ts // Hooks
useLogout.ts // Hooks
..
components/ // View
業務やfeatureに依っては、usecasesやentitiesは不要なこともある
例えば、fetchしてきたものを整形して表示するだけのfeatureでは不要になる
各Layerの責務
View
見た目に関することのみが責務
Componentとstyleを書く
Component内ではhooksか親からもらったJSONを色付けてして表示するだけ
ロジックはここには書かない
code:ts
const Component = () => {
// ここにはロジックを書かない。hooksを呼ぶだけ
return <div>...</div>
}
もっと厳格にやるならContainerとPresentationに分けるべきなんだろうなmrsekut.icon
Custom Hooks層
責務ごとに分けられたCustom Hooksを書く
ロジックをここに凝集させる
例えば、以下のようなことはcustom hooksの中でやる
actionを起こしたときのhandlerとなる関数の定義
react-hook-formのuseFormを呼ぶ
react-queryのuseQueryを呼ぶ
ReduxのuseSelectorを呼ぶ
etc.
react-queryを使っている場合は、fetcher関数がusecaseやrepositoryに相当する
code:ts
export const usePosts = () => {
const { data } = useQuery(
queryKeys.posts(),
Repository.getPosts, // これがUsecase.getPostsだったりする
{..}
);
return {..};
};
返り値はViewに渡されるわけだが、必要最低限のものを提供する
この返り値は、hooksの内部で使っているlibraryに依存させない
libraryをwrapする
こうしておけば、状態管理libraryがReduxからreact-queryに変わったときも、修正箇所を最小限に抑えられる
流石に、Reduxとreact-queryは思想が違いすぎるので本当にそこだけで抑えられるかは微妙だけど、Viewで直接useSelectorを書いている時に比べればかなり減らせるはずmrsekut.icon
Error Handlingはここで行う
UseCase層
不要なことも多いが、ロジックの多い箇所ではこの層を用意する
例えば、POST系の処理など
classは使う必要がない。関数の列挙で十分
内部でRepositoryの関数を呼ぶ
hooksに依存しないロジックはここに書けばいい
抽象度の高い処理の列挙になる事が多い
失敗する処理はthrowする
Hooks層がhandlingしてくれる
内部でRepositoryのErrorのhandlingも行うこともある
例えば、login処理の中で、付随してmodal表示などをする場合に、
後者が失敗したとして、loginそのものを失敗させる必要はない
loginが失敗したときだけ、失敗しました、と表示すれば良い
Repository層
backendとの通信や、WebStorageへの保存などの具体的なロジック
返り値はEntityの型になる
ここで、外部から受け取ったデータを完全に整形してUsecaseやHooksに返す
backendはRESTかもしれないし、GraphQLかもしれない
その差分はRepositoryで閉じ込める
RESTからGraphQLに変わった時に、Repositoryの修正だけで済むのが理想
Repositoryの内部では大まかに3パーツに分けられる
request部分
GETしたりPOSTしたりする処理
constructor部分
外部リソースをEntityに整形するための関数
外部リソースが返す型の定義
外部リソースの返す型
内部のEntityと全く同じであっても、定義しておいたほうが良い
通信でErrorが生じた場合などは基本的にthrowする
4xx、5xx等の場合はthrowさせる
react-queryやSWRは、fetcherがthrowしたかどうかで失敗を判定している
Eitherなどで表現した場合、retryとかisErrorとかの動作が正しく機能しない
LocalStorageやCookieの操作もRepositoryに書く
UsecaseやHooksから見れば、それらはインターネットの向こうにあるのか、ローカルにあるのかは意識しないで良い
Entity層
domain logicがあれば、それを実装する
Typeを定義するだけのfile
Entityの型などを定義する
この型定義がプロダクトの根幹である
ここがどうしようもないとfeature全体がどうしようもなくなるmrsekut.icon
どこでError Handlingするか?
適宜変えればよいが、基本方針としては以下のようになる
Repositoryは失敗したらthrowさせる
UseCaseは失敗したらthrowさせる
Hooksがhandlingする
↑これはreact-queryを使用していることを前提としているので、この限りではないmrsekut.icon
要は、Repository内でこういう風には書かないということ
外部から配列を受け取る例
code:repositoriies.ts
export const getPosts = async () => {
try {
const result = await get<ResPost[]>(/posts);
return result.map(toPost);
} catch {
return []; // こういう風には書かない
}
}
通信に失敗した場合に空配列を返している
そうではなく、Hooks層までerrorを伝搬させる
RepositoryやUsecaseの中でErrorを揉み消さないほうが良い
明確な意図があるならそうしても良いけど
失敗時にretryなどをしてくれる
上記のものからもっと改善できる
RSCを導入することで、層を薄くできそう
react-queryなどがそもそも不要
PBFをもっと強める
こうじゃなくて
https://gyazo.com/73a176b9b7c1685cd12a9fba5dfe90e3
こうする
https://gyazo.com/a5f42719dcbe27d7642a2e37e6a28fc6
実際はこんな感じになる気がした
https://gyazo.com/94c6e85be09b861d0ba0578241f0b791
つまり、Hooksに階層を入れて2種類に分けるmrsekut.icon
例えばフォームのvalidationなどが書かれたもの
まあ、これについてはComponentと同じfileに書けば良いじゃん、という気もする
が、fileがでかくなるのももにょる
でもfile名のprefixとかでルール付けするよりはよぽど良いな
特定のViewに依存しないロジック
こういう風に2つに分けることで、
一番大きな円(View+Hooks, Repository)の中で、
だから、2つ目以降の円(Hooks, UseCase, Entity)内では仕様を満たした型を使用できる
form validationは?
Viewの責務
上の話は、Custom Hooksのレイヤーが雑になっている
Custom Hooksの中で、別のCustom Hooksを呼ぶことはあるわけで、
ここに依存関係の曖昧さが存在する
安定度の高いhooksもあれば、頻繁に修正されるhooksも存在するはずで、
それを何らかのルールに則ってレイヤー化した方が本当は良いはず
mrsekut.iconはいまのところ問題にぶつかってないので、そこまでモチベがないけども、複数人があれば問題は生じるはず
Componentの中にでは、Atomic Design等のlayerが存在する
userIdを取得するhooksみたいなのが、いろんなところのcustomHooksで使われすぎるようにならないか?
1つのものに大量に依存があるのは大丈夫なのかどうか
つまりここで言う「便利な Hooks」は「特定のバックエンドに密結合にする代わりに、めちゃくちゃ便利にバックエンドを使えるようになる Hooks」
まさに
ESPってなんだmrsekut.icon
react-queryを使った場合の、repository的なものの置き場所の話
案1 そもそもbackendが整形したデータを返せばいい
GraphQLを使っているのと同じ感じ
それができれば苦労しないmrsekut.icon
案2 queryFnの中でやる
mrsekut.iconはこれでやってた
デメリットはrefetchのたびにこの変換が行われること
例えば、react-queryからreudxに移行した場合も変更が少なくなると思うmrsekut.icon
案3 customhooksの、値をreturnする箇所で変換する
なんかぐちゃぐちゃになりそうな気もするけど?
ここでrepository的なものを呼べばそうでもないか
useMemoで適宜囲う
「データの整形」とは別に、custom hookの仕事として「データの計算」がある場合に、一緒にやった結果をmemo化できるので、案2よりも効率化される可能性がある
案4 useQueryのselectを使う
知らなかったmrsekut.iconmrsekut.icon*2これでええやんmrsekut.icon
docs全部読まないといけないなと思いつつ実践しているが、やはり量が多すぎて抜け落ちてしまう
dataが未定義になることをきにしないでいい
select内に含める関数はuseCallbackで作った方が良い
あるいはhooks外に作ればいいか
これめっちゃ良いな
でかいresponseを返すRESTがあった場合に、
{a: Big1, b: Big2}
hooksの責務としては、aとbは別々に管理したかった
だからkeyを分けて
['a']のrepositoryで↑をまるごと取って、aだけ返す
['b']のrepositoryで↑をまるごと取って、bだけ返す
ということをしていたので、requestが部分的に倍になっていたが、
↑わかりにくすぎるので、ちゃんと書こうmrsekut.icon
このアプローチを使うことで、それを軽減できる
でもこれだと、server側のキモい型がhooks内まで出てきちゃうな
hooksをrepository内に作ればどうにかなる
まあ、そもそもGraphQLを使えばこの辺は問題にならないか
ということで、案2のままで行く